/**
 * Copyright 2014 opencxa.org
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *     http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */
package org.cxa.rpc.cloud.client;

import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.concurrent.TimeUnit;

import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
import org.cxa.exec.simpleThread.SimpleThread;
import org.cxa.rpc.messages.RequestRpcMessage;
import org.cxa.rpc.messages.ResponseRpcMessage;
import org.cxa.rpc.provider.ComponentTreeProvider;
import org.cxa.rpc.provider.RpcException;
import org.cxa.rpc.session.SessionManager;
import org.cxa.rpc.session.SessionManager.AsynchronousRequestListener;
import org.cxa.rpc.session.SessionManager.SessionListenerAdapter;
import org.cxa.rpc.transportChannel.TransportChannel.DisconnectReason;
import org.cxa.rpc.transportChannel.json.webSocket.ClientWebSocketJsonTransportChannel;
import org.cxa.stateMachine.State;
import org.cxa.stateMachine.StateMachine;
import org.cxa.timeUtils.TimeDiff;


/**
 * This is an implementation of a RpcClient which connects to a
 * RpcServer over a socket connection (TCP/IP).
 * 
 * @author Christopher Armenio
 */
public abstract class CloudClient extends ComponentTreeProvider
{
	public static final int DEFAULT_PORT_NUM = 2014;
	public static final String DEFAULT_HOSTNAME = "localhost";
	private static final int CONNECT_BACKOFF_PERIOD_MIN_MS = 3000;
	public static final int CONNECT_BACKOFF_PERIOD_MAX_MS = 10000;
	
	public static final String PARAM_NAME_AUTH_TYPE = "authType";
	
	
	public interface CloudClientListener
	{
		public void onConnect();
		public void onDisconnect(DisconnectReason reasonIn, boolean willAttemptReconnectIn);
		public void onFailedAuthentication(String errorIn);
	}
	
	
	public static class CloudClientListenerAdapter implements CloudClientListener
	{
		@Override
		public void onConnect() {}

		@Override
		public void onDisconnect(DisconnectReason reasonIn, boolean willAttemptReconnectIn) {}

		@Override
		public void onFailedAuthentication(String errorIn) {}
	}
	
	
	private class ConnectionManager extends SimpleThread
	{
		@Override
		public void run()
		{
			while(this.shouldKeepRunning())
			{
				stateMachine.update();
				Thread.yield();
			}
		}
	}
	
	
	private enum RpcCloudClientState
	{
		RCC_STATE_IDLE,
		RCC_STATE_CONNECTING,
		RCC_STATE_AUTHENTICATING,
		RCC_STATE_CONNECTED,
		RCC_STATE_CONNECT_BACKOFF,
	}
	
	
	private final StateMachine<RpcCloudClientState> stateMachine;
	
	private final boolean attemptReconnect;
	private DisconnectReason lastDisconnectReason = DisconnectReason.UNKNOWN;
	private final Logger logger;
	
	private final ConnectionManager connMan = new ConnectionManager();
	private boolean debug_skipAuthentication = false;
	
	private List<CloudClientListener> listeners = new ArrayList<CloudClientListener>();
	
	
	/**
	 * Instantiates a device that will eventually connect to the openCXA cloud server.
	 * The device will not actually attempt to connect to the server until {@link #start()} is called.
	 * 
	 * @param serialNumIn the serial number of this device
	 * @param passcodeIn the passcode for this device
	 * @param attemptReconnectIn true if the client should automatically attempt to reconnect
	 * 		if it is disconnected (for any reason)
	 */
	public CloudClient(boolean attemptReconnectIn)
	{
		super(new SessionManager(new ClientWebSocketJsonTransportChannel()));
		
		// save our references
		this.attemptReconnect = attemptReconnectIn;
		
		// setup our session manager
		this.logger = LogManager.getLogger(this.toString());
		this.logger.debug(String.format("owns %s, %s", this.connMan.toString(), this.getSessionManager().toString()));
		
		// setup our listener
		this.getSessionManager().addSessionListener(new SessionListenerAdapter()
		{
			@Override
			public void onSessionOpen()
			{
				stateMachine.transition(RpcCloudClientState.RCC_STATE_AUTHENTICATING);
			}

			@Override
			public void onSessionClose(DisconnectReason reasonIn)
			{
				lastDisconnectReason = reasonIn;
				if( attemptReconnect ) stateMachine.transition(RpcCloudClientState.RCC_STATE_CONNECT_BACKOFF);
				else stateMachine.transition(RpcCloudClientState.RCC_STATE_IDLE);
			}
		});
		
		// setup our stateMachine
		this.stateMachine = new StateMachine<RpcCloudClientState>(this.toString());
		this.stateMachine.addState(new State<RpcCloudClientState>(RpcCloudClientState.RCC_STATE_IDLE)
		{
			@Override
			public void enter(RpcCloudClientState prevStateIn)
			{
				logger.info("becoming idle");
				((ClientWebSocketJsonTransportChannel)getSessionManager().getTransportChannel()).disconnect();
				getSessionManager().stop();
				connMan.stop();
			}
		});
		
		this.stateMachine.addState(new State<RpcCloudClientState>(RpcCloudClientState.RCC_STATE_CONNECTING)
		{
			@Override
			public void enter(RpcCloudClientState prevStateIn)
			{
				logger.info("connecting to server");
				
				lastDisconnectReason = DisconnectReason.UNKNOWN;
				if( !((ClientWebSocketJsonTransportChannel)getSessionManager().getTransportChannel()).connect(
						String.format("ws://%s:%d", DEFAULT_HOSTNAME, DEFAULT_PORT_NUM)) )
				{
					logger.debug("connection failed");
					if( attemptReconnect ) stateMachine.transition(RpcCloudClientState.RCC_STATE_CONNECT_BACKOFF);
					else stateMachine.transition(RpcCloudClientState.RCC_STATE_IDLE);
				}
			}
			
			@Override
			public void state()
			{
				// keep updating our SessionManager
				getSessionManager().update();
			}
		});
		
		this.stateMachine.addState(new State<RpcCloudClientState>(RpcCloudClientState.RCC_STATE_AUTHENTICATING)
		{
			@Override
			public void enter(RpcCloudClientState prevStateIn)
			{
				logger.info("authenticating");
				if( debug_skipAuthentication ) stateMachine.transition(RpcCloudClientState.RCC_STATE_CONNECTED);
				
				ResponseRpcMessage response = sendRequest("/sys/auth", 
						((CloudClient.this instanceof TransientDevice) ? "authTransient" : "authDevice"),
						getAuthenticationParameters());
				
				
				// check to see if we were successfully authenticated
				if( !response.getWasSuccessful() )
				{
					// we encountered an actual error (not just a failed authentication)
					RpcException e = response.getException();
					logger.warn(e.getFormattedErrorString());
					for( CloudClientListener currListener : listeners )
					{
						currListener.onFailedAuthentication(e.getFormattedErrorString());
					}
					disconnect();
				}
				else
				{
					// we didn't get an error back, but we need to check the return value...
					Boolean wasAuthenticated = (Boolean)response.getReturnValue(Boolean.class);
					if( (wasAuthenticated == null) || !wasAuthenticated )
					{
						String errorString = String.format("invalid credentials");
						logger.warn(errorString);
						for( CloudClientListener currListener : listeners )
						{
							currListener.onFailedAuthentication(errorString);
						}
						disconnect();
					}
					else
					{	
						logger.info("authentication successful");
						stateMachine.transition(RpcCloudClientState.RCC_STATE_CONNECTED);
					}
				}
			}
			
			@Override
			public void state()
			{
				// keep updating our SessionManager
				getSessionManager().update();
			}
		});
		
		this.stateMachine.addState(new State<RpcCloudClientState>(RpcCloudClientState.RCC_STATE_CONNECTED)
		{
			@Override
			public void enter(RpcCloudClientState prevStateIn)
			{
				logger.info("connected");
				for(CloudClientListener currListener : listeners)
				{
					currListener.onConnect();
				}
			}
			
			@Override
			public void state()
			{
				getSessionManager().update();
			}
			
			@Override
			public void leave(RpcCloudClientState nextStateIn)
			{
				logger.info("disconnected");
				for(CloudClientListener currListener : listeners)
				{
					currListener.onDisconnect(lastDisconnectReason, attemptReconnect);
				}
			}
		});
		
		this.stateMachine.addState(new State<RpcCloudClientState>(RpcCloudClientState.RCC_STATE_CONNECT_BACKOFF)
		{
			private int backoffPeriod_ms = CONNECT_BACKOFF_PERIOD_MAX_MS;
			private TimeDiff td_backOff = new TimeDiff();
			
			@Override
			public void enter(RpcCloudClientState prevStateIn)
			{
				// calculate a random backOff period
				this.backoffPeriod_ms = CONNECT_BACKOFF_PERIOD_MIN_MS + (int)(Math.random() * (CONNECT_BACKOFF_PERIOD_MAX_MS - CONNECT_BACKOFF_PERIOD_MIN_MS));
				logger.debug(String.format("backoff for %d ms", this.backoffPeriod_ms));
				
				// start our timeDiff
				this.td_backOff.setStartTime_now();
			}
			
			@Override
			public void state()
			{
				if( this.td_backOff.isElapsed(this.backoffPeriod_ms, TimeUnit.MILLISECONDS) ) stateMachine.transition(RpcCloudClientState.RCC_STATE_CONNECTING);
			}
		});
		
		this.stateMachine.transition(RpcCloudClientState.RCC_STATE_IDLE);
	}
	
	
	public void addListener(CloudClientListener cclIn)
	{
		if( cclIn == null ) throw new IllegalArgumentException("listener must NOT be null");
		this.listeners.add(cclIn);
	}
	
	
	public void connect()
	{
		if( this.connMan.isRunning() )
		{
			// our connection manager thread is running...see if we need to kick our state machine out of idle
			if( this.stateMachine.getCurrentState() == RpcCloudClientState.RCC_STATE_IDLE ) this.stateMachine.transition(RpcCloudClientState.RCC_STATE_CONNECTING);
		}
		else
		{
			// our connection manager thread is NOT running...tell our state machine to connect and start it!
			this.stateMachine.transition(RpcCloudClientState.RCC_STATE_CONNECTING);
			this.connMan.start();
		}
	}
	
	
	public boolean waitForConnect(long timeoutIn, TimeUnit timeUnitIn)
	{
		TimeDiff td_waitForStart = new TimeDiff();
		boolean retVal = false;
		while( !(retVal = this.isConnected()) )
		{
			if( td_waitForStart.isElapsed(timeoutIn, timeUnitIn) ) break;
			Thread.yield();
		}
		
		return retVal;
	}
	
	
	public boolean isConnected()
	{
		return (this.connMan.isRunning() && (this.stateMachine.getCurrentState() == RpcCloudClientState.RCC_STATE_CONNECTED));
	}
	
	
	public void disconnect()
	{
		this.stateMachine.transition(RpcCloudClientState.RCC_STATE_IDLE);
	}
	
	
	public boolean waitForDisconnect(long timeoutIn, TimeUnit timeUnitIn)
	{
		TimeDiff td_waitForStart = new TimeDiff();
		boolean retVal = false;
		while( !(retVal = !this.isConnected()) )
		{
			if( td_waitForStart.isElapsed(timeoutIn, timeUnitIn) ) break;
			Thread.yield();
		}
		
		return retVal;
	}
	
	
	public ResponseRpcMessage sendRequest(RequestRpcMessage msgIn)
	{
		return this.getSessionManager().sendRequest(msgIn);
	}
	
	
	public ResponseRpcMessage sendRequest(String destPathIn, String cmdIn)
	{
		return this.sendRequest(destPathIn, cmdIn, (Map<String, Object>)null);
	}
	
	
	public ResponseRpcMessage sendRequest(String destPathIn, String cmdIn, Map<String, Object> paramsIn)
	{
		return this.sendRequest(new RequestRpcMessage(destPathIn, cmdIn, paramsIn));
	}
	
	
	public boolean sendRequest(RequestRpcMessage msgIn, AsynchronousRequestListener listenerIn)
	{
		return this.getSessionManager().sendRequest(msgIn, listenerIn);
	}
	
	public boolean sendRequest(String destPathIn, String cmdIn, AsynchronousRequestListener listenerIn)
	{
		return this.sendRequest(destPathIn, cmdIn, null, listenerIn);
	}
	
	
	public boolean sendRequest(String destPathIn, String cmdIn, Map<String, Object> paramsIn, AsynchronousRequestListener listenerIn)
	{
		return this.sendRequest(new RequestRpcMessage(destPathIn, cmdIn, paramsIn), listenerIn);
	}
	
	
	protected abstract Map<String, Object> getAuthenticationParameters();
	
	
	public void debug_stopRespondingToKeepAlives()
	{
		this.getSessionManager().debug_stopRespondingToKeepAlives();
	}
	
	
	public void debug_skipAuthentication()
	{
		this.debug_skipAuthentication = true;
	}
	
	
	/*
	 * (non-Javadoc)
	 * @see java.lang.Object#toString()
	 */
	@Override
	public String toString()
	{
		return String.format("%s.%s", this.getClass().getSimpleName(), System.identityHashCode(this));
	}
}
